ROS2 与 DDS 互操作:wire 层约定与跨实现限制
ROS2 的通信后端是 DDS,但「都用了 DDS」不等于天然互通。一个用原生 DDS API 写的节点(比如自研中间件、跨系统桥接程序)要与 ROS2 节点互相收发数据,必须在 wire 层逐项对齐 ROS2 的约定。本文整理这些约定的原理与对齐清单:序列化发生在哪一层、topic 与类型名的变换规则、跨 DDS 实现互通的根本限制、Service wire 帧的头部注入,以及 QoS 静默失配的排查思路。适合做中间件适配、跨系统集成或排查「端点都在却收不到数据」问题的读者。ROS2 内部的序列化管线细节(rosidl 代码生成、CDR 编码规则)另见本站《ROS2 消息序列化:从 .msg 到 CDR 字节流》,本文聚焦「外部节点如何对上」。
ROS2 的序列化架构:DDS 只是管道
先建立一个关键认知:ROS2 没有使用 DDS 自带的序列化机制。序列化发生在 typesupport 层,由 rosidl 工具链生成,DDS 被降级为透明传输管道,只搬运 CDR 字节流,不参与编解码。
每种 DDS 后端对应一种 typesupport 策略:FastDDS 与 Connext 为每个消息类型生成专用序列化代码;CycloneDDS 走内省(introspection)路线,只生成字段元数据,运行时由通用函数遍历编解码。两种策略的对比展开在序列化一文中,这里只需要结论:
不同 rmw 实现的 ROS2 节点之间能互通,是因为 typesupport 层统一了 wire 约定——type name 映射、CDR 编码细节、消息头格式都一致。绕过 typesupport 直接用 DDS 原生 API 的节点,只能与 wire 约定逐项一致的对端互通。
wire 层命名规则
与 ROS2 互通的节点要遵守 rmw_cyclonedds_cpp 的命名约定,分 topic 前缀和类型名两部分。
Topic 命名前缀
| 通信模式 | 前缀 | 示例 |
|---|---|---|
| Topic | rt/ | rt/fibonacci/_action/feedback |
| Service request | rq/ | rq/fibonacci/_action/send_goalRequest |
| Service response | rr/ | rr/fibonacci/_action/send_goalReply |
前缀无尾斜杠,直接拼在话题名前。rmw_cyclonedds 内部用 make_fqtopic() 做这个拼接(源码位于 rmw_cyclonedds_cpp/src/rmw_node.cpp);原生 DDS 节点要么自己拼前缀,要么在封装层统一处理。
类型名变换(rosidl_dds 规则)
rosidl_generator_dds_idl 消费解析后的 IDL(不是原始 .msg),输出带尾下划线的 {MessageName}_.idl。三个结构性变换:
module dds_包装:struct 嵌套在额外的module dds_ { }中- struct 名尾下划线:
Test→Test_ - 成员名尾下划线:
bool_value→bool_value_
module test_msgs {
module msg {
module dds_ {
struct Test_ {
boolean bool_value_;
octet byte_value_;
long int32_value_;
};
};
};
}; 类型名公式:::'.join(namespaces + ['dds_', name + '_'])。例如 std_msgs/msg/Header 对应 wire 层类型名 std_msgs::msg::dds_::Header_。
基本类型映射
| ROS 类型 | DDS IDL | 备注 |
|---|---|---|
int8 / uint8 | octet | DDS 无 signed byte,符号信息在 IDL 层丢失 |
int16 / uint16 | short / unsigned short | |
int32 / uint32 | long / unsigned long | |
int64 / uint64 | long long / unsigned long long | |
string<=N | string<N> | 有界字符串 |
| 固定数组 | <type> <name>_[<size>] | |
| 有界序列 | sequence<<type>, <max>> <name>_ |
接口规模的展开也值得注意:一个 Message 对应一个 struct;一个 Service 拆成 request 与 response 两个;一个 Action 展开为七个接口——Goal、Result、Feedback 三个消息,send_goal、cancel_goal、get_result 三个服务,再加一个内层 FeedbackMessage。
跨 DDS 互通的根本限制
DDS 实现之间理论上可以互通(都遵循 CDR/RTPS 标准),但前提是双方对 wire 层的所有约定完全一致。实际障碍主要有三个:
- type name:端点发现时靠它匹配,不同 DDS 实现从同一份 IDL 生成的 type name 可能不同
- CDR 封装细节:string、sequence 等类型的对齐和编码方式
- TypeInformation 编码:DDS-XTypes 1.3 规定 TypeObject 用 XCDR2(PL_CDR2)编码,较早的实现使用 CDR1——解析方按错误格式读取类型信息,轻则匹配失败,重则直接异常
| 互通场景 | 是否可行 |
|---|---|
| 原生节点 ↔ 原生节点(同一 DDS 后端) | 是 |
原生节点 ↔ ROS2 rmw_cyclonedds_cpp | 是(对齐上述约定后) |
原生节点 ↔ ROS2 rmw_fastrtps_cpp | 通常不可行(TypeInformation 编码冲突,加上 typesupport 约定的差异) |
| 原生节点 ↔ 其他 DDS 实现的原生节点 | 取决于 wire 约定是否逐项一致 |
这也是很多团队在引入 ROS2 互通时统一 DDS 实现的原因:多实现互通意味着自行对齐全部 wire 约定并跟踪各实现的版本差异,通常成本高于收益。
Service 头部注入(header_guid / header_seq)
ROS2 Service 的 wire 帧在 CDR payload 前有一段额外头部,由 rmw_cyclonedds 的序列化器直接写入,不做字节对齐:
[RTPS header 4B][guid 8B][seq 8B][IDL payload...] guid:DataWriter 的dds_instance_handle_t(uint64_t)seq:全局递增的 int64_t- IDL struct 本身不含这些字段,它们是序列化器额外 prepend 的
原生 DDS 实现要模拟这个 wire 格式,需要把头部当作 IDL 的前两个字段(header_guid + header_seq),并保证 CDR 字节布局与序列化器的无对齐写入一致。注意 idlcxx(CycloneDDS 的 IDL 编译器)对 uint64 会插入 8 字节对齐 padding,若与无对齐写入错位会反序列化失败——特定版本组合下 padding 影响可能恰好被吸收,但这是脆弱点,升级版本时要重新验证。
QoS 与容错机制
ROS2 的三种容错 QoS
| QoS | 作用 | 触发 | 适用范围 |
|---|---|---|---|
| Deadline | 超时检测 | 期望时间内没收到新数据 | Topic |
| Liveliness | 存活检测 | 活跃性断言未在 lease 期内刷新 | Topic |
| Lifespan | 数据过期 | 数据存活超过设定时长被丢弃 | Topic |
关键限制:ROS2 的 Service 和 Action API 未暴露 Deadline/Liveliness 这类容错 QoS——它们只在 Topic 上可用。Service server 感知不到 client 的异常退出,Action 对端死亡时也不会触发回调(相关讨论见 ros2/rclcpp#2241,"No callbacks if client/server died",截至目前仍然 open)。该 issue 报告者的做法是补一条心跳 Topic 并配 Deadline QoS 检测对端存活,这也是社区通行的补偿模式。CycloneDDS 提供 WaitSet + StatusCondition + Listener 三种方式监听 QoS 违规,无需轮询。
QoS 兼容性是静默失败陷阱
DDS 端点匹配时,reader 要求的可靠性不能高于 writer 提供的。例如 RELIABLE reader 订阅 BEST_EFFORT writer,QoS 不兼容,DDS 会静默拒绝匹配——不报错,只是 matched 计数为 0。这类问题排查起来很困难,表现为「端点都在但收不到数据」「wait_for_server 超时」。Action 各通道的 QoS 也必须逐项与 ROS2 对齐(feedback 通道是 RELIABLE 而非 BEST_EFFORT,status 通道是 TRANSIENT_LOCAL + depth=1),否则触发同样的静默失配。
要点
- ROS2 的序列化在 typesupport 层,DDS 只是管道。绕过 typesupport 的原生 DDS 节点,只能与 wire 约定逐项一致的对端互通。
- wire 命名三件事:topic 前缀(
rt/、rq/、rr/)、类型名(dds_::Name_形态)、成员名尾下划线。CycloneDDS 的 XTypes 匹配可能检查 member name,命名不一致会静默不匹配。 - 跨 DDS 互通的前提是 wire 约定逐项一致:type name、CDR 编码、TypeInformation 编码。XCDR2 与 CDR1 的分歧是 CycloneDDS 与老版本 FastDDS 之间最常见的死结。
- int8/uint8 都映射 octet,符号信息在 IDL 层丢失。
- Service wire 帧的头部(guid + seq)由序列化器 prepend,非对齐写入;模拟时注意 idlcxx 的对齐 padding 与它错位的风险。
- QoS 不兼容是静默失败:RELIABLE reader 撞上 BEST_EFFORT writer 会被 DDS 静默拒绝,永远收不到数据。
- Service/Action 无原生 Deadline/Liveliness,通行做法是心跳 Topic 补偿。